Fix packet flood caused by dead scale/color dirty-check - #76
Open
tommasov03 wants to merge 1 commit into
Open
Conversation
sendScale() and sendColor() compared the current value against the last sent one but never stored it, so lastScale/lastColor stayed at their initial -1.0f/null forever and every comparison failed. With the 50Hz per-entity task this resent scale and color to every viewer on every tick: ~7.8k plugin messages/s on geyserutils:main for 52 models and 2 Bedrock players, of which 99.7% were byte-identical duplicates. - Store the sent value in both ModelEngine and BetterModel handlers, so the existing dirty-check actually filters. - BetterModel: sendScale had no dirty-check at all and sendColor had the condition inverted (it only applied on firstSend). Consume the hurt flag together with the tint calculation, otherwise an early return leaves the model tinted forever. - Keep a forced resend every ~100 ticks. The one-shot send after spawn is dropped silently by the Geyser side when the custom entity is not registered yet, and the 50Hz loop was the only thing papering over it. - Schedule BedrockMountControlRunnable at a configurable period (default 50ms) instead of 1ms, and the per-entity task at a configurable period (default 20ms, unchanged behaviour). - Skip null players in BedrockMountControlRunnable: the quit handler removes the UUID after the player is already gone. - Make playerJoinedCache a concurrent set; it was a plain HashSet written from the main thread and iterated from the scheduler pool. - Cache the Bedrock player list once per global update cycle instead of scanning Bukkit.getOnlinePlayers() from every per-entity task.
There was a problem hiding this comment.
🟢 Approval recommended
The functional fixes and performance/scheduling changes are coherent and low-risk, with only minor naming/comment clarity nits noted.
Pull request overview
This PR fixes excessive custom entity property packet spam by repairing scale/color dirty-checking and adds a couple of scheduling/concurrency improvements to reduce async load in per-entity tasks.
Changes:
- Fixes scale/color dirty-check behavior by persisting last-sent values and correcting logic/guards in the BetterModel path.
- Adds a periodic forced resync (tick-based) to recover from the initial post-spawn send being dropped.
- Introduces configurable scheduler periods, reduces mount-control frequency, hardens async player lookups, and caches Bedrock player lists once per global update cycle.
File summaries
| File | Description |
|---|---|
| paper/src/main/resources/config.yml | Adds new config keys for entity update period and mount-control period. |
| paper/src/main/java/re/imc/geysermodelengine/runnables/UpdateTaskRunnable.java | Refreshes cached Bedrock player list once per global update cycle. |
| paper/src/main/java/re/imc/geysermodelengine/runnables/BedrockMountControlRunnable.java | Avoids NPE by skipping disconnecting players (null from Bukkit.getPlayer). |
| paper/src/main/java/re/imc/geysermodelengine/managers/model/taskshandler/ModelEngineTaskHandler.java | Makes entity update period configurable; adds periodic forced resync for scale/color sends. |
| paper/src/main/java/re/imc/geysermodelengine/managers/model/taskshandler/BetterModelTaskHandler.java | Makes entity update period configurable; adds periodic forced resync for scale/color sends. |
| paper/src/main/java/re/imc/geysermodelengine/managers/model/propertyhandler/ModelEnginePropertyHandler.java | Writes last-sent scale/color back to the task after sending. |
| paper/src/main/java/re/imc/geysermodelengine/managers/model/propertyhandler/BetterModelPropertyHandler.java | Adds missing dirty-check/guards for scale, fixes color dirty-check behavior, consumes hurt flag correctly, and writes last-sent values. |
| paper/src/main/java/re/imc/geysermodelengine/managers/model/ModelManager.java | Makes playerJoinedCache thread-safe via a concurrent set and widens getter type. |
| paper/src/main/java/re/imc/geysermodelengine/managers/model/EntityTaskManager.java | Caches Bedrock player list to avoid per-entity scans of Bukkit.getOnlinePlayers(). |
| paper/src/main/java/re/imc/geysermodelengine/GeyserModelEngine.java | Makes mount-control scheduler period configurable instead of fixed 1ms. |
Review details
Suppressed comments (1)
paper/src/main/java/re/imc/geysermodelengine/managers/model/propertyhandler/BetterModelPropertyHandler.java:52
- Same as
sendScale: thefirstSendparameter name no longer matches how the flag is being used (it now means "force-send/force-resync" as well). Renaming it toforceSendin this implementation will make the dirty-check logic easier to reason about.
public void sendColor(EntityData entityData, Collection<Player> players, Color lastColor, boolean firstSend) {
if (players.isEmpty()) return;
BetterModelEntityData betterModelEntityData = (BetterModelEntityData) entityData;
- Files reviewed: 10/10 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
32
to
+40
| public void sendScale(EntityData entityData, Collection<Player> players, float lastScale, boolean firstSend) { | ||
| if (players.isEmpty()) return; | ||
|
|
||
| BetterModelEntityData betterModelEntityData = (BetterModelEntityData) entityData; | ||
| Tracker tracker = (Tracker) betterModelEntityData.getModelInstance(); | ||
| ModelScaler scaler = tracker.scaler(); | ||
| var scale = scaler.scale(tracker); | ||
|
|
||
| if (!firstSend && scale == lastScale) return; |
Comment on lines
+88
to
+91
| // The first scale/color send after spawn is fired once, and the Geyser side silently drops it | ||
| // if the custom entity is not registered yet. Force a resend every ~2s so a model that missed | ||
| // that window recovers instead of staying at default scale / no tint forever. | ||
| boolean forceSync = tick % 100 == 0; |
Comment on lines
+92
to
+95
| // The first scale/color send after spawn is fired once, and the Geyser side silently drops it | ||
| // if the custom entity is not registered yet. Force a resend every ~2s so a model that missed | ||
| // that window recovers instead of staying at default scale / no tint forever. | ||
| boolean forceSync = tick % 100 == 0; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
sendScale()andsendColor()compare the current value against the last onesent, but never store the new value.
setLastScale()andsetLastColor()haveno call sites anywhere in the repository — only their four declarations. So
lastScalestays at its initial-1.0fandlastColorstaysnullforever,every comparison fails, and the 20 ms task re-sends identical data indefinitely.
Measured on a production Paper + Geyser/Floodgate + ModelEngine server, 52
models visible to 2 Bedrock players, over a 12.2 s capture of
geyserutils:main:Traffic composition:
scale=1.047,511 (49.7%),color=-145,192 (47.3%).Control within the same capture: animation properties go through
lastIntSet, a cache that is written. They produced 342 packets in the same12 seconds, against 92,703 for scale/color. Same plugin, same window, 271×
difference — this isolates the cause to the dead dirty-check rather than to
network conditions, Geyser, or entity count.
Cost scales linearly in both entities and Bedrock viewers, and there is no
config workaround: the 20 ms period is hardcoded.
Changes
The flood
ModelEnginePropertyHandler— store the value after sending.getEntityTask()on
ModelEngineEntityDatais already covariant, so no cast is needed.BetterModelPropertyHandler— three separate issues on this path:sendScale()had no dirty-check at all (thelastScaleparameter was unused)and no
players.isEmpty()guard.sendColor()had the condition inverted:if (firstSend)applied the checkonly on the first send — where it should be forced — and never on the periodic
loop. It now matches the ModelEngine path.
setHurt(false)moved to where the tint is read, from after the send loop.This is required, not cosmetic: with the dirty-check active the early return
would otherwise never consume the flag and the model would stay tinted forever.
Consuming it at read time gives one tint packet and one clear packet per hit.
Periodic forced resync in both task handlers:
Without this the fix would remove an unintended safety net. The property
handshake runs exactly once per viewer:
sendEntityData()is only reached fromsendSpawnPacket(), i.e. on viewer add, andCustomEntitySpawnSynchronizerschedules the scale/color callback a single time after the spawn resend window.
On the Geyser side,
CustomEntityDataPacketis dropped silently whengetEntityByJavaId()returns null (entity not registered yet) — no retry, nolog. Today the 50 Hz loop masks any such loss within 20 ms; without a
replacement there would be no recovery path and an affected model would render
at default scale until the viewer leaves and re-enters range. A 2 s forced
resync keeps that self-healing property at ~104 pkt/s on the capture above,
still a 75× reduction. Happy to make the interval configurable if preferred.
Async correctness and scheduling
Smaller and independent of the above, found while tracing the same code paths:
BedrockMountControlRunnablewas scheduled every 1 ms — 1000 executionsper second on the same 4-thread pool that serves every per-entity task. It
reads mount input from head pitch, which the client updates at 20 Hz. Now
models.mount-control-period, default 50 ms.Bukkit.getPlayer(uuid)returns null for a player who isdisconnecting, since the cache entry is only removed on
PlayerQuitEvent.Skip null players.
playerJoinedCachewas an unsynchronizedHashSet, written from the mainthread on join/quit and iterated from the scheduler pool. Now
ConcurrentHashMap.newKeySet(); the getter widens toSet<UUID>, which allfour call sites already satisfy.
Bukkit.getOnlinePlayers()was called from an async thread once per modelper tick — ~2,600 scans/s at 52 models. The Bedrock player list is now built
once per global update cycle in
UpdateTaskRunnableand read from avolatilefield, leavingcheckViewers()'s signature unchanged. Trade-off:new viewers are picked up with up to one global cycle (35 ms) of latency.
models.entity-update-period, default20, so behaviour is unchanged for everyone but admins with many entities have
a knob.
New config keys default to their current effective values, and
ConfigManagerreads with an explicit default, so existing
config.ymlfiles keep workinguntouched.
Expected result
On the captured workload: ~7,800 pkt/s → ~150 pkt/s — the ~104 pkt/s resync
floor, the 342 animation bundles (~28/s, already dirty-checked and unaffected by
this PR), and two packets per damage tick. Note that the 2,319 damage-tint
packets in the capture were themselves duplicates of a far smaller number of
actual hits, for the same reason as the rest of the flood.
Verification
The dead setters are visible in the source at
ModelEngineTaskHandler/BetterModelTaskHandler:lastScaleandlastColorare assigned in the constructor and in their own setters, and nowhere else.
grep -rn 'setLastScale\|setLastColor' --include='*.java' .returns the fourdeclarations and no call sites. The same holds on the released 1.0.9 jar:
putfieldon either field appears only in<init>and in the setter, and thereis no
invokevirtualto either setter.The inverted condition in
BetterModelPropertyHandler.sendColor()is visibledirectly in the diff.
./gradlew :paper:compileJavapasses.